feat(adaptive-cards): add richCardTitleAsHeading styleOption to opt out of role=heading on rich card titles#5839
Conversation
…ut of role=heading on rich card titles Today the title of hero/thumbnail/audio/video/animation/receipt cards is rendered with Adaptive Cards style: 'heading', which the Adaptive Cards SDK exposes as role='heading' + aria-level. This was originally requested in issue microsoft#4327 and shipped in 4.15.3. Subsequent a11y audits (e.g. for hosts where these cards appear inside a chat transcript) flag the same heading as 'Unnecessary heading level is programmatically defined for Title' under MAS 1.3.1 / WCAG 1.3.1, because card titles inside a chat are not page-level headings and break document outline tools. Reconcile the two by making the behavior configurable via styleOptions.richCardTitleAsHeading. Default is true so existing consumers (including the original microsoft#4327 reporter) keep today's behavior; consumers can pass false to drop the heading style. Adds a sibling test heroCard.noHeading.html to the existing heroCard.heading.html that asserts no .ac-textBlock[role='heading'] is rendered when richCardTitleAsHeading is false.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR introduces a new Adaptive Cards style option to control whether rich card titles are rendered as programmatic headings for accessibility, defaulting to the historical behavior.
Changes:
- Added
styleOptions.richCardTitleAsHeading(defaulttrue) and documented it in the style options type. - Updated rich card header rendering to optionally omit Adaptive Cards
style: 'heading'. - Added an accessibility HTML test covering the “no heading” configuration and updated the changelog.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/bundle/src/adaptiveCards/defaultStyleOptions.ts | Adds a default value for the new richCardTitleAsHeading option. |
| packages/bundle/src/adaptiveCards/Attachment/AdaptiveCardBuilder.ts | Conditionally applies style: 'heading' to rich card titles based on the new option. |
| packages/bundle/src/adaptiveCards/AdaptiveCardsStyleOptions.ts | Documents and exposes the new style option in the public style options type. |
| tests/html2/accessibility/attachment/heroCard.noHeading.html | Adds an accessibility regression test ensuring no heading role is applied when opted out. |
| CHANGELOG.md | Announces the newly added style option. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@microsoft-github-policy-service agree |
1. heroCard.noHeading.html: scope queries to the hero card activity container instead of querying the whole document. Match the title text block by its expected text so future text blocks elsewhere on the page do not make the test flaky. 2. AdaptiveCardsStyleOptions.ts: drop the incomplete 'reverse request' bullet that had no link; keep the @see link to microsoft#4327 only. 3. AdaptiveCardBuilder.ts: add https:// prefix to the microsoft#4327 URL so tooling auto-links it.
1. AdaptiveCardBuilder.ts: drop 'as const' on the conditional 'style: heading'.
AGENTS.md says 'Avoid as'; the original code wrote 'style: heading'
without any cast because addTextBlock takes Partial<TextBlock>, and
TextBlock.style accepts string. Same here.
2. AdaptiveCardsStyleOptions.ts: tighten the doc-comment to match the
actual call graph instead of listing card types.
Cards that flow through addCommonHeaders today:
- hero (via addCommon)
- OAuth (direct)
- thumbnail no-image branch (via addCommon)
- animation/audio/video (via CommonCard -> addCommon)
Cards that DON'T (their titles use direct addTextBlock w/o style:heading):
- receipt
- thumbnail with images
- signin
| expect(titleTextBlock).toBeTruthy(); | ||
|
|
||
| expect(titleTextBlock.getAttribute('role')).toBe(null); | ||
| expect(heroCardActivity.querySelector('.ac-textBlock[role="heading"]')).toBe(null); |
There was a problem hiding this comment.
Suggestion: add both test one with visible styling for [role="heading"], and this one. Add snapshots for both so the change can be visually inspected.
There was a problem hiding this comment.
Good idea — added host.snapshot('local') to both heroCard.noHeading.html and heroCard.heading.html, and brought the heading test up to the same scoped-query pattern (locate the hero card activity, then match the title text block by its expected text and assert role='heading'). Both tests now also wait for allImagesLoaded so the captured snapshots are stable. Fixed in 74295fc.
OEvgeny
left a comment
There was a problem hiding this comment.
Looks good, waiting for the tests update
|
|
||
| ### Added | ||
|
|
||
| - Added `styleOptions.richCardTitleAsHeading` (default `true`) to opt out of `style: 'heading'` on rich card titles, by [@cjennison](https://github.com/cjennison). Resolves the conflict with [#4327](https://github.com/microsoft/BotFramework-WebChat/issues/4327) for hosts where card titles are not navigational headings. |
There was a problem hiding this comment.
Rewrote the entry in the standard single-line <verb> <desc>, in PR [#NNNN](url), by [@author](url) form. Fixed in 74295fc.
…format
- heroCard.noHeading.html: add host.snapshot('local') and wait for allImagesLoaded so the captured snapshot is stable.
- heroCard.heading.html: bring the default-styling test to parity with the noHeading test - scope queries to the hero card activity, assert role='heading' on the title text block, and add host.snapshot('local') so both behaviors can be visually compared (per OEvgeny).
- CHANGELOG.md: rewrite the entry in the repo's standard '<verb> <desc>, in PR [#NNNN](url), by [@author](url)' single-line form (per compulim).
Generated by running the docker compose / selenium / jest stack from a clean WSL Ubuntu build: npm install && npm run build docker compose -f docker-compose-wsl2.yml up --detach --scale chrome=2 ./node_modules/.bin/jest --ci=false --forceExit --runInBand --testPathPattern=heroCard -u Both snapshots are visually identical (as expected - the only difference is the role='heading' attribute on the title text block, which has no visual styling). The behavioural difference is asserted via the role assertions in each test's JS.
|
{"body":"Generated and committed the baseline image snapshots for both tests in eed2536. Pipeline used: clean WSL Ubuntu |


Summary
Make
role="heading"/aria-levelon hero/thumbnail/audio/video/animation/receipt card titles opt-out via a newstyleOptions.richCardTitleAsHeading(defaulttrue, preserves today's behavior).Why
Today
AdaptiveCardBuilder.addCommonHeaders()hardcodes Adaptive Cardsstyle: 'heading'on the card title TextBlock. The Adaptive Cards SDK then renders that asrole="heading"+aria-level.This was originally requested in issue #4327 (
Title in Hero Card does not havearia-levelspecified) and shipped in 4.15.3.However, downstream a11y audits — particularly for hosts where these cards appear inside a chat transcript (e.g. Microsoft Copilot Studio Test Chat) — flag the same heading as
Unnecessary heading level is programmatically defined for "Title"under MAS 1.3.1 / WCAG 1.3.1, because card titles inside a chat are not page-level headings and pollute the document outline that assistive tech relies on.The two requirements are mutually exclusive and both came from accessibility audits. The right fix is to make the host control it.
Behavior
styleOptions.richCardTitleAsHeadingtrue(default — unchanged from today)<div role="heading" aria-level="..." class="ac-textBlock">…</div>(per #4327)false<div class="ac-textBlock">…</div>(no programmatic heading)No breaking change — existing consumers keep today's output without action.
Test
Adds a sibling HTML test
__tests__/html2/accessibility/attachment/heroCard.noHeading.htmlthat mirrors the existingheroCard.heading.html, passesstyleOptions = { richCardTitleAsHeading: false }, and assertsdocument.querySelector('.ac-textBlock[role="heading"]')isnull.Changelog
Added under
[Unreleased]›Added.Notes
Required<AdaptiveCardsStyleOptions>updated in defaults.style: 'heading').addCommonHeadersis gated — that covers hero/thumbnail/audio/video/animation/receipt cards (every type that flows throughaddCommon).